# -*- coding: utf-8 -*-

# Copyright (c) 2011 - 2025 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a checker for password strength.
"""

import enum
import re


class PasswordStrength(enum.IntEnum):
    """
    Class defining the password strength.
    """

    VeryWeak = 0
    Weak = 1
    Good = 2
    Strong = 3
    VeryStrong = 4


class PasswordCheckStatus(enum.Enum):
    """
    Class defining the status of a password check.
    """

    Failed = 0
    Passed = 1
    Exceeded = 2


class PasswordChecker:
    """
    Class implementing a checker for password strength.
    """

    def __init__(self):
        """
        Constructor
        """
        self.score = {"count": 0, "adjusted": 0, "beforeRedundancy": 0}

        # complexity index
        self.complexity = {
            "limits": [20, 50, 60, 80, 100],
            "value": PasswordStrength.VeryWeak,
        }

        # check categories follow

        # length of the password
        self.passwordLength = {
            "count": 0,
            "minimum": 6,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.5,  # per character bonus
            "bonus": 10,  # minimum reached? Get a bonus.
            "penalty": -20,  # if we stay under minimum, we get punished
        }

        # recommended password length
        self.recommendedPasswordLength = {
            "count": 0,
            "minimum": 8,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 1.2,
            "bonus": 10,
            "penalty": -10,
        }

        # Basic requirements are:
        # 1) Password Length
        # 2) Uppercase letter use
        # 3) Lowercase letter use
        # 4) Numeric character use
        # 5) Symbol use
        self.basicRequirements = {
            "count": 0,
            "minimum": 3,  # have to be matched to get the bonus
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 1.0,
            "bonus": 10,
            "penalty": -10,
        }

        # how much redundancy is permitted, if the password is
        # long enough. we will skip the redudancy penalty if this
        # number is not exceeded (meaning redundancy < this number)
        self.redundancy = {
            "value": 1,  # 1 means, not double characters,
            # default to start
            "permitted": 2.0,  # 2 means, in average every character
            # can occur twice
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
        }

        # number of uppercase letters, such as A-Z
        self.uppercaseLetters = {
            "count": 0,
            "minimum": 1,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }

        # number of lowercase letters, such as a-z
        self.lowercaseLetters = {
            "count": 0,
            "minimum": 1,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }

        # number of numeric characters
        self.numerics = {
            "count": 0,
            "minimum": 1,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }

        # number of symbol characters
        self.symbols = {
            "count": 0,
            "minimum": 1,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }

        # number of dedicated symbols in the middle
        self.middleSymbols = {
            "count": 0,
            "minimum": 1,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }

        # number of dedicated numbers in the middle
        self.middleNumerics = {
            "count": 0,
            "minimum": 1,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 10,
            "penalty": -10,
        }

        # how many sequential characters should be checked
        # such as "abc" or "MNO" to be not part of the password
        self.sequentialLetters = {
            "data": "abcdefghijklmnopqrstuvwxyz",
            "length": 3,
            "count": 0,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": -1.0,
            "bonus": 0,
            "penalty": -10,
        }

        # how many sequential characters should be checked
        # such as "123" to be not part of the password
        self.sequentialNumerics = {
            "data": "0123456789",
            "length": 3,
            "count": 0,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": -1.0,
            "bonus": 0,
            "penalty": -10,
        }

        # keyboard patterns to check, typical sequences from your
        # keyboard
        self.keyboardPatterns = {
            # German and English keyboard text
            "data": [
                "qwertzuiop",
                "asdfghjkl",
                "yxcvbnm",
                '!"§$%&/()=',  # de
                "1234567890",  # de numbers
                "qaywsxedcrfvtgbzhnujmik,ol.pö-üä+#",  # de up-down
                "qwertyuiop",
                "asdfghjkl",
                "zyxcvbnm",
                "!@#$%^&*()_",  # en
                "1234567890",  # en numbers
                "qazwsxedcrfvtgbyhnujmik,ol.p;/[']\\",  # en up-down
            ],
            "length": 4,  # how long is the pattern to check and blame for?
            "count": 0,  # how many of these pattern can be found
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": -1.0,  # each occurrence is punished with that factor
            "bonus": 0,
            "penalty": -10,
        }

        # check for repeated sequences, like in catcat
        self.repeatedSequences = {
            "length": 3,
            "count": 0,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 0,
            "penalty": -10,
        }

        # check for repeated mirrored sequences, like in tactac
        self.mirroredSequences = {
            "length": 3,
            "count": 0,
            "status": PasswordCheckStatus.Failed,
            "rating": 0,
            "factor": 0.0,
            "bonus": 0,
            "penalty": -10,
        }

        self.uppercaseRe = re.compile("[A-Z]")
        self.lowercaseRe = re.compile("[a-z]")
        self.numberRe = re.compile("[0-9]")
        self.symbolRe = re.compile("[^a-zA-Z0-9]")

    def __strReverse(self, string):
        """
        Private method to reverse a string.

        @param string string to be reversed
        @type str
        @return reversed string
        @rtype str
        """
        return "".join(reversed(string))

    def __determineStatus(self, value):
        """
        Private method to determine the status.

        @param value value to check
        @type int
        @return status
        @rtype PasswordCheckStatus
        """
        if value == 0:
            return PasswordCheckStatus.Passed
        elif value > 0:
            return PasswordCheckStatus.Exceeded
        else:
            return PasswordCheckStatus.Failed

    def __determineBinaryStatus(self, value):
        """
        Private method to determine a binary status.

        @param value value to check
        @type int
        @return status
        @rtype PasswordCheckStatus
        """
        if value == 0:
            return PasswordCheckStatus.Passed
        else:
            return PasswordCheckStatus.Failed

    def checkPassword(self, password):
        """
        Public method to check a given password.

        @param password password to be checked
        @type str
        @return indication for the password strength
        @rtype PasswordStrength
        """
        # how long is the password?
        self.passwordLength["count"] = len(password)
        self.recommendedPasswordLength["count"] = len(password)

        # Loop through password to check for Symbol, Numeric, Lowercase
        # and Uppercase pattern matches
        for index in range(len(password)):
            if self.uppercaseRe.match(password[index]):
                self.uppercaseLetters["count"] += 1
            elif self.lowercaseRe.match(password[index]):
                self.lowercaseLetters["count"] += 1
            elif self.numberRe.match(password[index]):
                if index > 0 and index < len(password) - 1:
                    self.middleNumerics["count"] += 1
                self.numerics["count"] += 1
            elif self.symbolRe.match(password[index]):
                if index > 0 and index < len(password) - 1:
                    self.middleSymbols["count"] += 1
                self.symbols["count"] += 1

        # check the variance of symbols or better the redundancy
        # makes only sense for at least two characters
        if len(password) > 1:
            uniqueCharacters = []
            for index1 in range(len(password)):
                found = False
                for index2 in range(index1 + 1, len(password)):
                    if password[index1] == password[index2]:
                        found = True
                        break
                if not found:
                    uniqueCharacters.append(password[index1])

            # calculate a redundancy number
            self.redundancy["value"] = len(password) / len(uniqueCharacters)

        # Check for sequential alpha string patterns (forward and reverse)
        # but only, if the string has already a length to check for, does
        # not make sense to check the password "ab" for the sequential data
        # "abc"
        lowercasedPassword = password.lower()

        if self.passwordLength["count"] >= self.sequentialLetters["length"]:
            for index in range(
                len(self.sequentialLetters["data"])
                - self.sequentialLetters["length"]
                + 1
            ):
                fwd = self.sequentialLetters["data"][
                    index : index + self.sequentialLetters["length"]
                ]
                rev = self.__strReverse(fwd)
                if lowercasedPassword.find(fwd) != -1:
                    self.sequentialLetters["count"] += 1
                if lowercasedPassword.find(rev) != -1:
                    self.sequentialLetters["count"] += 1

        # Check for sequential numeric string patterns (forward and reverse)
        if self.passwordLength["count"] >= self.sequentialNumerics["length"]:
            for index in range(
                len(self.sequentialNumerics["data"])
                - self.sequentialNumerics["length"]
                + 1
            ):
                fwd = self.sequentialNumerics["data"][
                    index : index + self.sequentialNumerics["length"]
                ]
                rev = self.__strReverse(fwd)
                if lowercasedPassword.find(fwd) != -1:
                    self.sequentialNumerics["count"] += 1
                if lowercasedPassword.find(rev) != -1:
                    self.sequentialNumerics["count"] += 1

        # Check common keyboard patterns
        patternsMatched = []
        if self.passwordLength["count"] >= self.keyboardPatterns["length"]:
            for pattern in self.keyboardPatterns["data"]:
                for index in range(len(pattern) - self.keyboardPatterns["length"] + 1):
                    fwd = pattern[index : index + self.keyboardPatterns["length"]]
                    rev = self.__strReverse(fwd)
                    if (
                        lowercasedPassword.find(fwd) != -1
                        and fwd not in patternsMatched
                    ):
                        self.keyboardPatterns["count"] += 1
                        patternsMatched.append(fwd)
                    if (
                        lowercasedPassword.find(rev) != -1
                        and fwd not in patternsMatched
                    ):
                        self.keyboardPatterns["count"] += 1
                        patternsMatched.append(rev)

        # Try to find repeated sequences of characters.
        if self.passwordLength["count"] >= self.repeatedSequences["length"]:
            for index in range(
                len(lowercasedPassword) - self.repeatedSequences["length"] + 1
            ):
                fwd = lowercasedPassword[
                    index : index + self.repeatedSequences["length"]
                ]
                if (
                    lowercasedPassword.find(
                        fwd, index + self.repeatedSequences["length"]
                    )
                    != -1
                ):
                    self.repeatedSequences["count"] += 1

        # Try to find mirrored sequences of characters.
        if self.passwordLength["count"] >= self.mirroredSequences["length"]:
            for index in range(
                len(lowercasedPassword) - self.mirroredSequences["length"] + 1
            ):
                fwd = lowercasedPassword[
                    index : index + self.mirroredSequences["length"]
                ]
                rev = self.__strReverse(fwd)
                if (
                    lowercasedPassword.find(
                        fwd, index + self.mirroredSequences["length"]
                    )
                    != -1
                ):
                    self.mirroredSequences["count"] += 1

        # Initial score based on length
        self.score["count"] = (
            self.passwordLength["count"] * self.passwordLength["factor"]
        )

        # passwordLength
        # credit additional length or punish "under" length
        if self.passwordLength["count"] >= self.passwordLength["minimum"]:
            # credit additional characters over minimum
            self.passwordLength["rating"] = (
                self.passwordLength["bonus"]
                + (self.passwordLength["count"] - self.passwordLength["minimum"])
                * self.passwordLength["factor"]
            )
        else:
            self.passwordLength["rating"] = self.passwordLength["penalty"]
        self.score["count"] += self.passwordLength["rating"]

        # recommendedPasswordLength
        # Credit reaching the recommended password length or put a
        # penalty on it
        if self.passwordLength["count"] >= self.recommendedPasswordLength["minimum"]:
            self.recommendedPasswordLength["rating"] = (
                self.recommendedPasswordLength["bonus"]
                + (
                    self.passwordLength["count"]
                    - self.recommendedPasswordLength["minimum"]
                )
                * self.recommendedPasswordLength["factor"]
            )
        else:
            self.recommendedPasswordLength["rating"] = self.recommendedPasswordLength[
                "penalty"
            ]
        self.score["count"] += self.recommendedPasswordLength["rating"]

        # lowercaseLetters
        # Honor or punish the lowercase letter use
        if self.lowercaseLetters["count"] > 0:
            self.lowercaseLetters["rating"] = (
                self.lowercaseLetters["bonus"]
                + self.lowercaseLetters["count"] * self.lowercaseLetters["factor"]
            )
        else:
            self.lowercaseLetters["rating"] = self.lowercaseLetters["penalty"]
        self.score["count"] += self.lowercaseLetters["rating"]

        # uppercaseLetters
        # Honor or punish the lowercase letter use
        if self.uppercaseLetters["count"] > 0:
            self.uppercaseLetters["rating"] = (
                self.uppercaseLetters["bonus"]
                + self.uppercaseLetters["count"] * self.uppercaseLetters["factor"]
            )
        else:
            self.uppercaseLetters["rating"] = self.uppercaseLetters["penalty"]
        self.score["count"] += self.uppercaseLetters["rating"]

        # numerics
        # Honor or punish the numerics use
        if self.numerics["count"] > 0:
            self.numerics["rating"] = (
                self.numerics["bonus"]
                + self.numerics["count"] * self.numerics["factor"]
            )
        else:
            self.numerics["rating"] = self.numerics["penalty"]
        self.score["count"] += self.numerics["rating"]

        # symbols
        # Honor or punish the symbols use
        if self.symbols["count"] > 0:
            self.symbols["rating"] = (
                self.symbols["bonus"] + self.symbols["count"] * self.symbols["factor"]
            )
        else:
            self.symbols["rating"] = self.symbols["penalty"]
        self.score["count"] += self.symbols["rating"]

        # middleSymbols
        # Honor or punish the middle symbols use
        if self.middleSymbols["count"] > 0:
            self.middleSymbols["rating"] = (
                self.middleSymbols["bonus"]
                + self.middleSymbols["count"] * self.middleSymbols["factor"]
            )
        else:
            self.middleSymbols["rating"] = self.middleSymbols["penalty"]
        self.score["count"] += self.middleSymbols["rating"]

        # middleNumerics
        # Honor or punish the middle numerics use
        if self.middleNumerics["count"] > 0:
            self.middleNumerics["rating"] = (
                self.middleNumerics["bonus"]
                + self.middleNumerics["count"] * self.middleNumerics["factor"]
            )
        else:
            self.middleNumerics["rating"] = self.middleNumerics["penalty"]
        self.score["count"] += self.middleNumerics["rating"]

        # sequentialLetters
        # Honor or punish the sequential letter use
        if self.sequentialLetters["count"] == 0:
            self.sequentialLetters["rating"] = (
                self.sequentialLetters["bonus"]
                + self.sequentialLetters["count"] * self.sequentialLetters["factor"]
            )
        else:
            self.sequentialLetters["rating"] = self.sequentialLetters["penalty"]
        self.score["count"] += self.sequentialLetters["rating"]

        # sequentialNumerics
        # Honor or punish the sequential numerics use
        if self.sequentialNumerics["count"] == 0:
            self.sequentialNumerics["rating"] = (
                self.sequentialNumerics["bonus"]
                + self.sequentialNumerics["count"] * self.sequentialNumerics["factor"]
            )
        else:
            self.sequentialNumerics["rating"] = self.sequentialNumerics["penalty"]
        self.score["count"] += self.sequentialNumerics["rating"]

        # keyboardPatterns
        # Honor or punish the keyboard patterns use
        if self.keyboardPatterns["count"] == 0:
            self.keyboardPatterns["rating"] = (
                self.keyboardPatterns["bonus"]
                + self.keyboardPatterns["count"] * self.keyboardPatterns["factor"]
            )
        else:
            self.keyboardPatterns["rating"] = self.keyboardPatterns["penalty"]
        self.score["count"] += self.keyboardPatterns["rating"]

        # Count our basicRequirements and set the status
        self.basicRequirements["count"] = 0

        # password length
        self.passwordLength["status"] = self.__determineStatus(
            self.passwordLength["count"] - self.passwordLength["minimum"]
        )
        if self.passwordLength["status"] != PasswordCheckStatus.Failed:
            # requirement met
            self.basicRequirements["count"] += 1

        # uppercase letters
        self.uppercaseLetters["status"] = self.__determineStatus(
            self.uppercaseLetters["count"] - self.uppercaseLetters["minimum"]
        )
        if self.uppercaseLetters["status"] != PasswordCheckStatus.Failed:
            # requirement met
            self.basicRequirements["count"] += 1

        # lowercase letters
        self.lowercaseLetters["status"] = self.__determineStatus(
            self.lowercaseLetters["count"] - self.lowercaseLetters["minimum"]
        )
        if self.lowercaseLetters["status"] != PasswordCheckStatus.Failed:
            # requirement met
            self.basicRequirements["count"] += 1

        # numerics
        self.numerics["status"] = self.__determineStatus(
            self.numerics["count"] - self.numerics["minimum"]
        )
        if self.numerics["status"] != PasswordCheckStatus.Failed:
            # requirement met
            self.basicRequirements["count"] += 1

        # symbols
        self.symbols["status"] = self.__determineStatus(
            self.symbols["count"] - self.symbols["minimum"]
        )
        if self.symbols["status"] != PasswordCheckStatus.Failed:
            # requirement met
            self.basicRequirements["count"] += 1

        # judge the requirement status
        self.basicRequirements["status"] = self.__determineStatus(
            self.basicRequirements["count"] - self.basicRequirements["minimum"]
        )
        if self.basicRequirements["status"] != PasswordCheckStatus.Failed:
            self.basicRequirements["rating"] = (
                self.basicRequirements["bonus"]
                + self.basicRequirements["factor"] * self.basicRequirements["count"]
            )
        else:
            self.basicRequirements["rating"] = self.basicRequirements["penalty"]
        self.score["count"] += self.basicRequirements["rating"]

        # beyond basic requirements
        self.recommendedPasswordLength["status"] = self.__determineStatus(
            self.recommendedPasswordLength["count"]
            - self.recommendedPasswordLength["minimum"]
        )
        self.middleNumerics["status"] = self.__determineStatus(
            self.middleNumerics["count"] - self.middleNumerics["minimum"]
        )
        self.middleSymbols["status"] = self.__determineStatus(
            self.middleSymbols["count"] - self.middleSymbols["minimum"]
        )
        self.sequentialLetters["status"] = self.__determineBinaryStatus(
            self.sequentialLetters["count"]
        )
        self.sequentialNumerics["status"] = self.__determineBinaryStatus(
            self.sequentialNumerics["count"]
        )
        self.keyboardPatterns["status"] = self.__determineBinaryStatus(
            self.keyboardPatterns["count"]
        )
        self.repeatedSequences["status"] = self.__determineBinaryStatus(
            self.repeatedSequences["count"]
        )
        self.mirroredSequences["status"] = self.__determineBinaryStatus(
            self.mirroredSequences["count"]
        )

        # we apply them only, if the length is not awesome
        if self.recommendedPasswordLength["status"] != PasswordCheckStatus.Exceeded:
            # repeatedSequences
            # Honor or punish the use of repeated sequences
            if self.repeatedSequences["count"] == 0:
                self.repeatedSequences["rating"] = self.repeatedSequences["bonus"]
            else:
                self.repeatedSequences["rating"] = (
                    self.repeatedSequences["penalty"]
                    + self.repeatedSequences["count"] * self.repeatedSequences["factor"]
                )

            # mirroredSequences
            # Honor or punish the use of mirrored sequences
            if self.mirroredSequences["count"] == 0:
                self.mirroredSequences["rating"] = self.mirroredSequences["bonus"]
            else:
                self.mirroredSequences["rating"] = (
                    self.mirroredSequences["penalty"]
                    + self.mirroredSequences["count"] * self.mirroredSequences["factor"]
                )

        # save value before redundancy
        self.score["beforeRedundancy"] = self.score["count"]

        # apply the redundancy
        # is the password length requirement fulfilled?
        if (
            self.recommendedPasswordLength["status"] != PasswordCheckStatus.Exceeded
            and self.score["count"] > 0
        ):
            # full penalty, because password is not long enough, only for
            # a positive score
            self.score["count"] *= 1.0 / self.redundancy["value"]

        # level it out
        if self.score["count"] > 100:
            self.score["adjusted"] = 100
        elif self.score["count"] < 0:
            self.score["adjusted"] = 0
        else:
            self.score["adjusted"] = self.score["count"]

        # judge it
        for index in range(len(self.complexity["limits"])):
            if self.score["adjusted"] <= self.complexity["limits"][index]:
                self.complexity["value"] = PasswordStrength(index)
                break

        return self.complexity["value"]
